from random import Random

from utils.fsm import FSM
from utils.logger import Logger
from utils.clock import Clock
from utils.gauge import Gauge
from utils.channel import Channel
from utils.namespace import Namespace
from utils.misc import INF, check_type

from .objective import ObjectiveFunction
from .solutions import SolverSolutionList
from .interrupts import SolverInterruptManager
from .plugins.limits import SolverLimitManager
from .plugins.manager import SolverPluginManager
from .plugins.displaystatus import DisplayStatus
from .plugins.checkoptimality import CheckOptimality
from .status import SolverStatusPrinter
from .params import SolverParam, SolverParamSet
from .termination import TerminationStatus, TERMINATION
from .statemachine import STATE, ACTION, TRANSITIONS
from .signals import SIGNALS


class Solver(FSM):
    """
    Base class for solvers. Defines a basic set of attributes, and a finite state machine (states,
    actions, transitions) that can be used by all solvers.
    """
    STATE = STATE              # namespace containing solver states
    ACTION = ACTION            # namespace containing solver actions (i.e. transition symbols)
    SIGNALS = SIGNALS          # namespace containing solver signals
    TERMINATION = TERMINATION  # namespace containing solver termination statuses

    Param = SolverParam        # parameter class used by the solver
    ParamSet = SolverParamSet  # parameter set class used by the solver
    instance_loader = None     # callable used for loading instances
    default_plugins = [DisplayStatus, CheckOptimality]  # plugins installed by default

    def __init__(self, **params):
        FSM.__init__(self)
        self.channel = Channel(name=type(self).__name__,    # asynchronous communication channel
                               type_validation=True,
                               register_types=self.SIGNALS.ALL)
        self.meta = Namespace()                             # metadata namespace
        self.instance = None                                # instance being solved
        self._objective = None                              # objective function
        self._incumbent = None                              # value of incumbent
        self._bound = None                                  # value of best bound
        self._termination = None                            # search termination status
        self.solutions = SolverSolutionList(self)           # list of solutions found
        self.interrupts = SolverInterruptManager(self)      # interrupt messages and actions
        self.limits = SolverLimitManager(self)              # solver run limits
        self.plugins = SolverPluginManager(self)            # solver plugins
        self.status = SolverStatusPrinter(self)             # display status messages
        self.rng = Random()                                 # standard RNG
        self.cpu = Clock()                                  # cpu tracker
        self.iters = Gauge()                                # iteration counter
        self.log = Logger()                                 # log manager
        # The next two operations (create paramset and install default plugins) must
        # only be done when all solver attributes have been set up, therefore they
        # are left to the end of the initializer.
        self.params = type(self).ParamSet(self, **params)   # configuration parameters
        self.plugins.extend(self.default_plugins)           # install default plugins
        self.enter(self.STATE.UNINITIALIZED)                # enter uninitialized state

    # --------------------------------------------------------------------------
    # Basic solver dynamic properties
    @property
    def objective(self):
        return self._objective

    @objective.setter
    def objective(self, objective):
        if self.state != self.STATE.UNINITIALIZED:
            raise Exception("cannot change objective function in state {!r}".format(self.state))
        if not isinstance(objective, ObjectiveFunction):
            objective = ObjectiveFunction(*objective)
        self._objective = objective

    @property
    def incumbent(self):
        return self._incumbent

    @incumbent.setter
    def incumbent(self, new_value):
        old_gap = self.gap
        old_value = self._incumbent
        if not self._objective.sense.is_better(new_value, old_value):
            raise ValueError("new incumbent must be better than previous incumbent")
        self._incumbent = new_value
        self.channel.emit(self.SIGNALS.INCUMBENT_CHANGED)
        if old_gap != self.gap:
            self.channel.emit(self.SIGNALS.GAP_CHANGED)

    @property
    def bound(self):
        return self._bound

    @bound.setter
    def bound(self, new_value):
        old_gap = self.gap
        old_value = self._bound
        if not self._objective.sense.is_better(old_value, new_value):
            raise ValueError("new bound must be tighter than previous bound")
        self._bound = new_value
        self.channel.emit(self.SIGNALS.BOUND_CHANGED)
        if old_gap != self.gap:
            self.channel.emit(self.SIGNALS.GAP_CHANGED)

    @property
    def gap(self):
        """Computes the gap (in [0, 1]) between the incumbent and the best bound, relative to the
        best bound."""
        incumbent = self._incumbent
        bound = self._bound
        return (0.0 if bound == incumbent else
                INF if bound == 0.0 else
                abs(incumbent - bound) / abs(bound))

    @property
    def termination(self):
        return self._termination

    @termination.setter
    def termination(self, status):
        if self._termination is not None:
            raise Exception("duplicate attempt to set solver termination status")
        if self.state not in (self.STATE.BOOTSTRAPPED, self.STATE.RUNNING, self.STATE.FINISHED):
            raise Exception("cannot set termination status in state {!r}".format(self.state))
        if isinstance(status, basestring):
            status = self.TERMINATION[status.upper()]
        elif status not in self.TERMINATION.itervalues():
            raise ValueError("unrecognized termination status: {!r}".format(status))
        check_type(status, TerminationStatus)
        self._termination = status
        self.interrupts.add(status.message, self.ACTION.FINISH)
        if status.signal is not None:
            self.channel.emit(status.signal)

    # --------------------------------------------------------------------------
    # Command-line interface, interactive session, and reporting
    def cli(self):
        """Run the solver's command-line interface tool using arguments from sys.argv (i.e. the
        arguments passed to the script)."""
        from .cli import solver_cli
        return solver_cli(self)

    def repl(self):
        """Launch an interactive session (read-eval-print loop) with this solver."""
        from .repl import SolverInteractiveSession
        cls = type(self)
        session = SolverInteractiveSession(self)
        return session.cmdloop(intro="{} version {} - interactive session" %
                               (cls.__name__, cls.version))

    def report(self, ostream=None):
        """Print a report of the search to 'ostream'. If no output stream is given, the solver's
        info log stream is used."""
        if ostream is None:
            ostream = self.log.info
        ostream.write(repr(self) + "\n")

    # --------------------------------------------------------------------------
    # Solver states
    @FSM.State
    def uninitialized(self):
        """Initial solver state. It is an undefined state used when the solver is not conducting a
        search."""
        self.channel.emit(self.SIGNALS.SOLVER_UNINITIALIZING)
        self.meta.clear()
        self.meta.solver_class = str(type(self))
        self.solutions.clear()
        self.interrupts.clear()
        self._termination = None
        self.limits.clear()
        self.cpu.clear()
        self.iters.clear()
        self._reset()
        self.channel.emit(self.SIGNALS.SOLVER_UNINITIALIZED)

    @FSM.State
    def initialized(self, instance=None, *limits, **params):
        """After entering this state, the solver has received an instance and is ready to start a
        search. The search always starts by transitioning into bootstrap and only then starts the
        main loop in the 'running' state."""
        self.channel.emit(self.SIGNALS.SOLVER_INITIALIZING)
        if self._objective is None:
            self.log.error("Objective function undefined. Please define an objective function.")
            self.log.info("Resetting...")
            return self.input(self.ACTION.RESET)
        self._incumbent = self._objective.sense.worst_value
        self._bound = self._objective.sense.best_value
        self.limits.set(limits)
        params.setdefault("seed", None)
        self.params.set(params)
        self.meta.initial_params = self.params.get()
        if instance is not None:
            if isinstance(instance, basestring) and self.instance_loader is not None:
                # If an instance was provided as a string and a loader function is available, we
                # use the loader to load the instance based on its "name".
                # Solver parameters are set before loading an instance because the instance loader
                # may be set in the argument parameters.
                self.meta.instance = instance
                self.log.info("Loading instance {!r}...", instance)
                self.channel.emit(self.SIGNALS.INSTANCE_LOADING)
                self.instance = self.instance_loader(instance)
                self.log.info("Instance {!r} loaded.", instance)
                self.channel.emit(self.SIGNALS.INSTANCE_LOADED)
            else:
                self.instance = instance
        if self.instance is None:
            self.log.error("Instance not found. Please provide an instance to init().")
            self.log.info("Resetting...")
            return self.input(self.ACTION.RESET)
        self._init()
        self.channel.emit(self.SIGNALS.SOLVER_INITIALIZED)

    @FSM.State
    def bootstrapped(self, *limits):
        """This state is used to set any initial state for the search before entering the main
        loop in the 'running' state.  To do so, subclasses should merely implement _bootstrap(),
        which is by default a no-op method.  Solvers that do not required bootstrap behavior may
        use the default _bootstrap() method."""
        with self.cpu.tracking():
            if len(limits) > 0:
                self.limits.set(limits)
            self.channel.emit(self.SIGNALS.SOLVER_BOOTSTRAPPING)
            self._bootstrap()
            self.channel.emit(self.SIGNALS.SOLVER_BOOTSTRAPPED)
            if len(self.interrupts) > 0:
                return self.interrupts.apply()
            return self.input(self.ACTION.RUN)

    @FSM.State
    def running(self, *limits):
        """The solver's main loop. The solver calls _iterate() until an interrupt is received.
        Subclasses are required to implement the _iterate() method."""
        with self.cpu.tracking():
            self.channel.emit(self.SIGNALS.SOLVER_RUNNING)
            # set new limits if provided
            if len(limits) > 0:
                self.limits.set(limits)
            # clear previous interrupts and check limits before the main loop
            self.interrupts.clear()
            for limit in self.limits:
                limit.check(None)
            try:
                # iterate until an interrupt is received
                while len(self.interrupts) == 0:
                    self.channel.emit(self.SIGNALS.ITERATION_STARTING)
                    self._iterate()
                    self.iters.incr()
                    self.channel.emit(self.SIGNALS.ITERATION_FINISHED)
            except KeyboardInterrupt:
                self.channel.emit(self.SIGNALS.KEYBOARD_INTERRUPT)
                self.interrupts.add("keyboard interrupt", self.ACTION.PAUSE)
            # displays interrupt messages and performs corresponding actions (PAUSE/FINISH)
            return self.interrupts.apply()

    @FSM.State
    def paused(self):
        """This state is used when the solver is interrupted but the search space is still not
        completely explored."""
        self.channel.emit(self.SIGNALS.SOLVER_PAUSING)
        self.channel.emit(self.SIGNALS.SOLVER_PAUSED)
        return self.solutions.best()

    @FSM.State
    def finished(self):
        """Final state used when the solver exhausted the search space."""
        self.channel.emit(self.SIGNALS.SOLVER_FINISHING)
        self.meta.total_cpu = self.cpu.total
        self.meta.total_iters = self.iters.total
        self._finish()
        if self.termination is None:
            self.termination = self.TERMINATION.UNKNOWN
        self.channel.emit(self.SIGNALS.SOLVER_FINISHED)
        self.report()
        return self.solutions.best()

    @FSM.State
    def solving(self, instance=None, *limits, **params):
        """This is a shortcut state. Entering this state is equivalent to initializing the solver
        and then running the search. Since an input shortcut method is available for each symbol
        in ACTION, using this state is as simple as
            solution = solver.solve(instance, cpu=60.0)
        """
        self.input(self.ACTION.INIT, instance, *limits, **params)
        if self.state != self.STATE.INITIALIZED:
            self.log.error("unexpected solver state after {!r}: {!r}", self.ACTION.INIT, self.state)
            return None
        return self.input(self.ACTION.RUN)

    # --------------------------------------------------------------------------
    # Solver methods that can/should be redefined in subclasses
    def _reset(self):
        """Set clean-slate state on the solver.  Should be used by subclasses to reset their own
        attributes."""
        pass

    def _init(self):
        """Initialize state on the solver.  Should be used by subclasses to initialize their own
        attributes."""
        pass

    def _bootstrap(self):
        """Implement in subclasses where bootstrapping behavior is required."""
        pass

    def _iterate(self):
        """Implementation required in solver subclasses."""
        raise NotImplementedError()

    def _finish(self):
        """Called when the solver is finishing a search."""
        pass

    def _extract_solution_and_meta(self, sol_container):
        """This method is used by the solver's solution list object to extract solution data and
        metadata from a solution container.  A solution container may hold more information than
        necessary to define a solution, so this method is useful to store only relevant solution
        data in the solution list.  The default implementation simply returns the container,
        unchanged, as the solution data, and an empty metadata dictionary."""
        return sol_container, {}


# attach a transition function to each state, as specified in the TRANSITIONS mapping
for state_name in STATE.itervalues():
    state = getattr(Solver, state_name)
    state.transition_fnc = FSM.State.transition_fnc_from_mapping(TRANSITIONS[state_name])

# create shortcut methods for each action in ACTIONS (e.g. pause, run, init, etc.)
for action in ACTION.itervalues():
    setattr(Solver, action, Solver.input_shortcut(action, name=action))
